Repository navigation
Host the Qwen3.8-Flash CUDA-graph decode loop in the Engine's static path - #2728
Open
Tianlei Wu (tianleiwu) wants to merge 17 commits into
Open
Tianlei Wu (tianleiwu) wants to merge 17 commits into
Tianlei Wu (tianleiwu) wants to merge 17 commits into
Conversation
This was referenced Oct 13, 2026
Merged
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Oct 14, 2026
…th auxiliary sessions (#2725) ## Description Adds `session_options.custom_ops_libraries`, a list of custom-op libraries registered in order after the existing single `custom_ops_library`, and exports `InheritSessionOptionsFromParent` so components that create auxiliary sessions (for example MTP/draft heads) can inherit the decoder's providers, provider options, config entries and custom ops. Models whose graphs use several custom-op domains (fused attention, hyper-connection, GEMM and head ops shipped next to the model package) currently cannot name more than one library. This change is generic and has no dependency on any model type; it is the first step of the Qwen3.8-Flash static-graph backend series (see the end of this description), but stands alone and targets `main`. ## Summary of Changes | File | Change | |------|--------| | `src/config.h`, `src/config.cpp` | New `session_options.custom_ops_libraries` (JSON string array; scalar values such as `"custom_ops_libraries": "ops.so"` are rejected during parsing). Overlays append to `custom_ops_libraries` (and `[]` leaves existing entries unchanged). `InheritSessionOptionsFromParent` exposes the existing inheritance helper; `custom_ops_library` and `custom_ops_libraries` are inherited together when the child sets neither (note that `"custom_ops_libraries": []` leaves the vector empty and therefore still inherits from the parent unless `custom_ops_library` is set). | | `src/models/model.cpp` | `custom_ops_library` and then every `custom_ops_libraries` entry go through the same path-traversal and allow-list checks, with duplicate resolved paths registered once. All paths are validated against the allow-list before loading starts; validated libraries are then loaded and registered sequentially in order (so a later load/registration error can still leave earlier libraries registered on `session_options`). | | `test/CMakeLists.txt`, `test/cpp/engine/custom_ops_test_lib/custom_ops_test_lib.cpp`, `test/cpp/engine/custom_ops_libraries_tests.cpp` | Builds two small test custom-op shared libraries (`custom_ops_test_first` and `custom_ops_test_second`) and tests parsing/ordering, overlay rules, rejection of non-string entries and scalar values, inheritance (including `[]` and `CreateMtpDecoderConfig`), allow-list validation, and actual library loading, ordering, and deduplication. | ### Library Location Requirement Every entry in `custom_ops_library` and `custom_ops_libraries` must resolve to an existing file inside one of the allowed directories: 1. The model folder (alongside `genai_config.json`), 2. A registered Execution Provider (EP) library directory, or 3. The current working directory. Previously, an unresolved bare library name in `custom_ops_library` was passed directly to `RegisterCustomOpsLibrary` and could be resolved through the platform loader search path (`LD_LIBRARY_PATH` / `PATH`). Unresolved entries are now rejected before loading so the directory allow-list cannot be bypassed; users should place custom-op libraries in one of the allowed directories above (typically the model folder). ## Testing - CPU-only Release build (`-DUSE_CUDA=OFF`) on `main`: `engine_unit_tests` **929 passed, 0 failed, 2 skipped** (including all 9 tests in `CustomOpsLibrariesConfigTest`, which load real test custom-op shared libraries to verify registration, ordering, and deduplication). - `lintrunner`, `clang-format` (20.1.8) and `git diff --check` are clean. ## Motivation and Context Found while hosting a model that uses seven custom-op libraries. Registering them through one `custom_ops_library` entry was not possible, and auxiliary sessions did not inherit the decoder's custom ops, so head graphs failed to load without repeating the whole session configuration. This is the first PR of a stack that adds an Engine-hosted CUDA-graph MTP backend for Qwen3.8-Flash-Next (greedy only, one resident request, Linux/CUDA, no prefix cache). The follow-ups are drafts stacked on `kvaishnavi/qwen-38-flash-run`, so this commit is also carried as the first commit of the next PR (#2726) until this one merges. The stack: #2726 (CUDA data plane library) -> #2727 (model type and device engine) -> #2728 (Engine hosting) -> #2729 (qualification tools). ## Checklist - [x] Tests added/updated - [x] No breaking changes - [ ] Documentation updated (the option is described in the PR; no dedicated config documentation exists for `session_options` yet) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
force-pushed
the
tlwu/20261013/ga-b4-qwen38-engine-host
branch
from
October 15, 2026 04:48
7463063 to
9a9a946
Compare
Tianlei Wu (tianleiwu)
force-pushed
the
tlwu/20261013/ga-b4-qwen38-engine-host
branch
2 times, most recently
from
October 19, 2026 08:57
be1c6f9 to
4719a3f
Compare
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Oct 30, 2026
…culative stats - Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset(). - Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens. - Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
force-pushed
the
tlwu/20261013/ga-b4-qwen38-engine-host
branch
from
October 30, 2026 05:12
4719a3f to
17b0299
Compare
3 tasks done
Contributor
Author
|
Rebased onto the new head of #2727 and force-pushed ( |
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Oct 30, 2026
…culative stats - Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset(). - Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens. - Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
force-pushed
the
tlwu/20261013/ga-b4-qwen38-engine-host
branch
from
October 30, 2026 05:44
17b0299 to
083043a
Compare
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Oct 30, 2026
…culative stats - Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset(). - Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens. - Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
force-pushed
the
tlwu/20261013/ga-b4-qwen38-engine-host
branch
from
October 30, 2026 06:24
083043a to
e864af9
Compare
Tianlei Wu (tianleiwu)
marked this pull request as ready for review
October 30, 2026 18:34
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Nov 1, 2026
…culative stats - Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset(). - Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens. - Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Nov 1, 2026
The wasted look-ahead cycle of a stop-string or canceled turn is never waited for, so its record stays unconsumed and only the device status bits can tell that it failed. The production device keeps that record after Drain() and, since #2727, CheckDecodeStatus() reads the accumulated bits also then; the Reset() that follows clears them. The executor already drains and checks the status before every Reset() (Begin, Release, end of turn). The scripted device now behaves like the production one so that this is actually exercised: Drain() completes the running cycle but leaves its record in flight, the cycle can accumulate error bits (fail_cycle), CheckDecodeStatus() reads them with a cycle in flight or unconsumed, and the device logs the drain/status/reset order. New tests: a failing look-ahead cycle of a stop-string turn surfaces when the turn is rewound and fails the next request instead of being reset away; the same for a canceled turn; a clean drained cycle lets the next request run. Document the Drain() + CheckDecodeStatus() contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
force-pushed
the
tlwu/20261013/ga-b4-qwen38-engine-host
branch
from
November 1, 2026 05:36
1b03cb3 to
cd0ddc7
Compare
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Nov 1, 2026
A cycle that the Engine ends inside its accepted prefix (turn or session limit, stop string, EOS) was reported with min(proposed, accepted + 1) evaluated drafts. With three accepted drafts but room for one token that is two evaluated drafts for one accepted one, a 50% acceptance rate where Engine::RecordSpeculativeCommit reports 100%. Count the draft positions the turn examined up to its terminal token, like Request::CommitAcceptedDraftsForTransaction does: every accepted draft it consumed (an EOS draft is examined but not accepted) plus the rejected position only when the turn reached the correction token. The scripted device gains multi-draft cycles (SetDrafts: 1..3 drafts per verify, any accepted prefix, reported through accepted_count/proposed_count like the production device). Tests cover limit, session limit, stop string and EOS truncation inside the accepted prefix, on the correction token and on the bonus token; five of them fail with the old formula. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Nov 1, 2026
A round that accepted some but not all of its drafts only recorded its histogram entry: the zero/full classification skipped it and partial_accept_rounds was never incremented, nor merged by AddStats. Classify every round like Engine::RecordSpeculativeCommit does, by the number of accepted drafts that were delivered (none: zero, all of the proposed ones: full, otherwise partial), independently of whether a correction token followed, and merge partial_accept_rounds in AddStats. Bonus and correction tokens keep being counted on their own. Tests cover a 0/1/2/3 accepted sequence of three-draft rounds (counters, histogram, correction/bonus, acceptance rate), a partial round whose correction does not fit the turn, a round truncated inside its accepted drafts, an EOS first draft (zero accept) and the sum over requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Nov 1, 2026
…l total The executor hands the prompt length of a turn to its own device (ExpectPrefillTokens, after Reset() and before the first chunk) and never publishes it through the process environment. Pin that with a scripted-device test: two Engines on two owner threads, each with its own model and device, are held at a rendezvous between the publication of the length and the first chunk of every turn, and each device must keep its own total across the chunks of a new turn, a continued turn that re-ingests the history after a Reset, and a canceled turn, while ORT_QMOE_PREFILL_TOTAL_TOKENS stays untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
added a commit
that referenced
this pull request
Nov 1, 2026
…capability clamping, and prompt validation - Use FixedSlotScheduler in CreateQwen38GraphDependencies and Scheduler::Create for continuous multi-slot caches, and require scheduler.SupportsIndependentSlotAdmission() for BackendAdmission::IndependentSlots. - Clamp resolved and declared static max_request_length to model.context_length as well as cache capacity. - Validate prompt token IDs against model.vocab_size at turn admission (and in Qwen38GraphSession::Begin) before mutating Request or device state. - Ensure restartable canceled and rewound nonresident requests remain enrolled in StaticBatchScheduler and FixedSlotScheduler without duplicates. - Use span.h in qwen38_graph_test_doubles.h and clarify scoped Qwen38OrtEnvScope publication in docs/paged_attention_engine.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Replay re-enrollment can duplicate paged requests and make the Engine unhealthy, while repeated rewind attempts can clear a fatal device error and permit execution on a poisoned device.
3 open issues
10 resolved since last review
Use the span polyfill to preserve supported GCC builds Persist prefill progress and re-enroll nonresident replay requests Re-enroll canceled requests when admitting a new turn Validate all prompt IDs before admitting a turn Poison the context when rewind status validation fails Reject zero request limits when the cache has finite capacity Clamp static request limits to model context length Preserve active requests when admitting another fixed-slot request Use or remove the unreferenced FixedSlotScheduler Clarify that scoped environment publication does not isolate unrelated ORT sess…
Review effort: Max
…path Serves `qwen38_flash_graph` through the existing Engine seams so requests, turns, cancellation, stop conditions and speculative statistics stay owned by the Engine. The backend is a single-slot static path: one resident request, greedy decoding, no prefix cache; further requests queue behind it in the static batch scheduler. - `Qwen38GraphCacheManager`: static-cache contract for one row. - `Qwen38GraphExecutor`: a `ModelExecutor` that selects its own tokens. One Engine step is one prompt chunk (the device's dense prefill chunk size) or one whole decode cycle (the folded draft/verify/commit cycle, whose one or two committed tokens are published through `Request::CommitExternalTokens`). Device error bits are checked when a turn ends; a continued or rewound turn re-ingests its history through the same chunked prefill. - `Qwen38GraphSession`: the platform- and CUDA-independent logic between the executor and a `Qwen38DecodeDevice`. - Engine hooks, all defaulting to the previous behaviour for other models: turn validation with capability errors (sampling, repetition penalty, minimum token counts, seeds and oversized sessions are rejected up front), static-path stop strings and limits, speculative-statistics reporting, and `Engine::CreateDependencies` returning early for this model type before any IndexShare or MTP setup. - `Request::CommitExternalTokens` applies EOS, length limits and stop strings per committed token. - Tests (scripted device, no GPU): 34 executor/session cases covering dense chunk streaming and hand-off, cancel during prefill and decode, stop strings, EOS handling, continuation and rewind, all-rejected and all-accepted cycles, device errors, and capability errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…culative stats - Propagate unchecked device decode status errors in Qwen38GraphSession::Release() and Begin() (and capture deferred teardown errors) so turns ending via stop strings, cancellation, rewind, or replacement still check device validation bits before Reset(). - Feed the committed RequestStepResult back to Qwen38GraphSession::OnStepCommitted() after Request::CommitExternalTokens so tokens_emitted, tokens_discarded, and accepted/bonus counts reflect actual committed tokens. - Add unit tests and speculative-stats assertions for EOS, stop-string, and canceled turns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n Qwen38GraphSession - Extend Qwen38StepResult::tokens to 4 tokens and update PendingStep/OnStepCommitted speculative stats for multi-draft S=2..4 cycles while preserving committed-token clipping. - Export ORT_QMOE_PREFILL_TOTAL_TOKENS at the start of prompt prefill so QMoE memory lending can size the prefill budget. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…evice API The graph model now sizes its prompt chunks from model.qwen38_graph.prefill.chunk (model.qwen38_graph.prefill_chunk_tokens is removed) and receives the expected prompt length through the device instead of the process environment. - CreateQwen38GraphDependencies validates the chunk configuration with ValidateQwen38PrefillChunk and sizes the session from EffectiveQwen38PrefillChunkTokens; a device that names its own chunk still overrides it. - Qwen38GraphSession no longer writes ORT_QMOE_PREFILL_TOTAL_TOKENS: it calls Qwen38DecodeDevice::ExpectPrefillTokens(total) after Reset() and before the first PrefillChunk() of every turn. - The scripted device implements ExpectPrefillTokens (device-local, dropped by Reset()/BeginDecode()) and records the total every chunk saw; the tests use prefill.chunk, cover the default and invalid chunk configurations, the session's even rounding, and the announcement order. - docs/paged_attention_engine.md describes the chunk sizing and the announcement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The wasted look-ahead cycle of a stop-string or canceled turn is never waited for, so its record stays unconsumed and only the device status bits can tell that it failed. The production device keeps that record after Drain() and, since #2727, CheckDecodeStatus() reads the accumulated bits also then; the Reset() that follows clears them. The executor already drains and checks the status before every Reset() (Begin, Release, end of turn). The scripted device now behaves like the production one so that this is actually exercised: Drain() completes the running cycle but leaves its record in flight, the cycle can accumulate error bits (fail_cycle), CheckDecodeStatus() reads them with a cycle in flight or unconsumed, and the device logs the drain/status/reset order. New tests: a failing look-ahead cycle of a stop-string turn surfaces when the turn is rewound and fails the next request instead of being reset away; the same for a canceled turn; a clean drained cycle lets the next request run. Document the Drain() + CheckDecodeStatus() contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A cycle that the Engine ends inside its accepted prefix (turn or session limit, stop string, EOS) was reported with min(proposed, accepted + 1) evaluated drafts. With three accepted drafts but room for one token that is two evaluated drafts for one accepted one, a 50% acceptance rate where Engine::RecordSpeculativeCommit reports 100%. Count the draft positions the turn examined up to its terminal token, like Request::CommitAcceptedDraftsForTransaction does: every accepted draft it consumed (an EOS draft is examined but not accepted) plus the rejected position only when the turn reached the correction token. The scripted device gains multi-draft cycles (SetDrafts: 1..3 drafts per verify, any accepted prefix, reported through accepted_count/proposed_count like the production device). Tests cover limit, session limit, stop string and EOS truncation inside the accepted prefix, on the correction token and on the bonus token; five of them fail with the old formula. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A round that accepted some but not all of its drafts only recorded its histogram entry: the zero/full classification skipped it and partial_accept_rounds was never incremented, nor merged by AddStats. Classify every round like Engine::RecordSpeculativeCommit does, by the number of accepted drafts that were delivered (none: zero, all of the proposed ones: full, otherwise partial), independently of whether a correction token followed, and merge partial_accept_rounds in AddStats. Bonus and correction tokens keep being counted on their own. Tests cover a 0/1/2/3 accepted sequence of three-draft rounds (counters, histogram, correction/bonus, acceptance rate), a partial round whose correction does not fit the turn, a round truncated inside its accepted drafts, an EOS first draft (zero accept) and the sum over requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…l total The executor hands the prompt length of a turn to its own device (ExpectPrefillTokens, after Reset() and before the first chunk) and never publishes it through the process environment. Pin that with a scripted-device test: two Engines on two owner threads, each with its own model and device, are held at a rendezvous between the publication of the length and the first chunk of every turn, and each device must keep its own total across the chunks of a new turn, a continued turn that re-ingests the history after a Reset, and a canceled turn, while ORT_QMOE_PREFILL_TOTAL_TOKENS stays untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ot hosting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
std::make_shared is nodiscard in the MSVC standard library, and EXPECT_THROW discarded its result, so every Windows build (warnings as errors) failed on test/cpp/engine/backend_factory_tests.cpp. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ScheduledRequests::StageExecutorTokens, executor_tokens_ and legacy_executor_tokens_ had no caller: the Qwen3.8 executor and the test doubles declare their step with BeginBackendStep and hand each row over with StageBackendRow, which validate the whole batch and carry a completion identity. The unvalidated, acknowledgement-less legacy branch could only be reached by a future caller that bypassed those checks. The SelectsOwnTokens documentation now names the protocol that is actually used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…d limit-of-one edges The design doc said that at Engine teardown the device is only drained. Qwen38GraphSession::~Qwen38GraphSession runs Release(), which also reads the decode status and, when anything fails, poisons the device context because nobody is left to report to. Also: include <exception> where std::exception_ptr is used, drop the empty stale banner in fixed_slot_scheduler.cpp and a stray '+ 0' in a disabled benchmark, and add two engine tests: a turn limit of one ends on the prompt prediction without starting a cycle, and a session that fills the whole KV capacity ends with the context-limit reason without overflowing the device ledger. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…so its turn can restart Engine::BeginTurn restarts a turn that was canceled before the request made any progress, even after another request took the request's device state, and it enrolls only the first turn of a request. Both schedulers dropped a displaced request from their pool when its turn was complete, so the restarted turn was never scheduled and the Engine waited forever. A displaced request whose canceled turn is restartable now stays enrolled; any other displaced request is forgotten as before. Tests: a canceled prefill request runs again after another request took the slot, and while the slot is still its own. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…er caught it Release() reads the device decode status outside a step when it rewinds or retires a request. RunStatic's fatal handler does not see that failure, and the next Reset() erases the error bits, so a caller that caught the exception went on with a healthy-looking session. The failure now marks the context failed and is kept for the next step, the way ReleaseForTeardown() already keeps its own, and the caller still gets the exception. Test: a device error found while rewinding poisons the context and fails the next step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… cache cannot honor A declared max_request_length of 0 means that the model search limit applies, which a cache that holds a finite number of positions cannot honor. The capability check compared only a non-zero declaration against the resolved length, so the contradiction went through and surfaced as an overflow during a long generation. A declared zero now fails the check whenever the resolved length is finite. Test: a declared zero against a 64-position cache throws, and a declared 64 does not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…capability clamping, and prompt validation - Use FixedSlotScheduler in CreateQwen38GraphDependencies and Scheduler::Create for continuous multi-slot caches, and require scheduler.SupportsIndependentSlotAdmission() for BackendAdmission::IndependentSlots. - Clamp resolved and declared static max_request_length to model.context_length as well as cache capacity. - Validate prompt token IDs against model.vocab_size at turn admission (and in Qwen38GraphSession::Begin) before mutating Request or device state. - Ensure restartable canceled and rewound nonresident requests remain enrolled in StaticBatchScheduler and FixedSlotScheduler without duplicates. - Use span.h in qwen38_graph_test_doubles.h and clarify scoped Qwen38OrtEnvScope publication in docs/paged_attention_engine.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e errors, and capability docs DynamicBatchScheduler::AddRequest now ignores a request that is already in the pool. Engine::BeginTurn enrolls rewound and restarted-after-cancel requests again, and the dynamic pool only drops a request through RemoveRequest, so the request was planned twice in one step (a fatal planning inconsistency, and four existing rewind tests failed). Qwen38GraphSession::Release rethrows the deferred device error without clearing it. Only a step consumes it, so retrying a failed rewind or retirement keeps failing instead of succeeding on a poisoned device. Document that max_request_length is the smaller of the model context_length and KV capacity minus the reserved tail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tianlei Wu (tianleiwu)
force-pushed
the
tlwu/20261013/ga-b4-qwen38-engine-host
branch
from
November 1, 2026 09:54
cd0ddc7 to
1786dd4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Description
Serves the
qwen38_flash_graphmodel type (#2727) through the Engine's existing static path. The CUDA-graph decode loop (the MTP draft -> verify -> commit cycle, the folded cycle, the dense fast prefill and the shared weight pool) stays on the device; the Engine keeps ownership of requests, turns, cancellation, stop conditions and speculative statistics. It is a single-slot backend: one resident request, greedy decoding, no prefix cache; further requests queue behind it in the static batch scheduler. Multi-slot hosting comes with ragged batching later in the series.Multi-draft cycles of 2 to 4 tokens (S=2..4) are reported through
Qwen38StepResult::tokensand the speculative statistics. The prompt length of a turn is handed to the device withQwen38DecodeDevice::ExpectPrefillTokens(after the deviceReset(), before the first prompt chunk), so QMoE memory lending can size the prefill budget from state that belongs to that device; the executor itself neither reads nor writes the process environment (ORT_QMOE_PREFILL_TOTAL_TOKENSis published by the device only around a single run). The prompt chunk size comes fromEffectiveQwen38PrefillChunkTokensof the reworkedqwen38_flash_graphconfiguration (#2727).Summary of Changes
src/engine/qwen38_graph_executor.{h,cpp}Qwen38GraphCacheManager(static-cache contract for one row),Qwen38GraphExecutor(aModelExecutorthat selects its own tokens: one Engine step is one prompt chunk of the size the device asks for, or one whole decode cycle) andQwen38GraphSession(platform- and CUDA-independent logic between the executor and aQwen38DecodeDevice; checks the device error bits when a turn ends; continuation and rewind re-ingest the history through the same chunked prefill; the speculative statistics count the drafts that were actually evaluated and classify rounds by the drafts they delivered, asEngine::RecordSpeculativeCommitdoes).src/engine/engine.cpp,model_executor.h,cache_manager.hRequestmutation (sampling, repetition penalty, no-repeat n-grams, minimum token counts, seeds, guidance and oversized sessions are rejected with a clear error), speculative-statistics reporting, static batch and length limits, andCreateDependenciesreturning early for this model type before any IndexShare/MTP setup.src/engine/request.{h,cpp},scheduled_requests.{h,cpp}Request::CommitExternalTokensapplies EOS, length limits and stop strings per committed token (precedence: stop string, EOS, turn limit, context limit; later tokens of a cycle are dropped);ScheduledRequestsstages the executor's tokens. Stop strings are accepted on this static path only.docs/paged_attention_engine.mdExpectPrefillTokens, theDrain()+CheckDecodeStatus()contract and the speculative statistics.test/cpp/engine/qwen38_graph_executor_tests.cpp,qwen38_graph_test_doubles.hQwen38DecodeDeviceand the real Engine: dense chunk streaming (any size, shorter than or exactly the chunk, hand-off exactly once and only after the last chunk), hand-off skipped when the first token ends the turn, cancellation between chunks and during decode, stop strings, EOS, continuation and rewind, all-rejected and all-accepted cycles, device errors (including failures that only show afterDrain()), capability errors, evaluated-draft and round counts of truncated rounds, and the expected prefill total of two Engines on two owner threads. The scripted device completes cycles asynchronously like the real one (Drain()leaves the record in flight,CheckDecodeStatus()reports the accumulated status bits,Reset()clears them).The dynamic/paged Engine path and the MTP path added by #2714 are not changed: the new
Request/ScheduledRequestsentry points are only reached from this executor, which never attaches drafts (MaxDraftTokensPerStep() == 0).Review feedback incorporated
Qwen38GraphSession::Release()andBegin()propagate an unchecked device decode status error (and capture a deferred teardown error), so a turn that ends through a stop string, cancellation, rewind or replacement still checks the device validation bits beforeReset().RequestStepResultis fed back toQwen38GraphSession::OnStepCommitted()afterRequest::CommitExternalTokens, sotokens_emitted,tokens_discardedand the accepted and bonus counts reflect the tokens that were actually committed. Tests cover EOS, stop-string and canceled turns.Drain()(the look-ahead cycle that is completed when a turn is canceled, ends on a stop string or is rewound) fails the request instead of being cleared by the nextReset(). Every such path runsDrain()and thenCheckDecodeStatus(), which on the reworked device contract (Add the qwen38_flash_graph model type and its device decode engine #2727) reads the status bits accumulated since the lastReset()even when a finished cycle record is left unconsumed, so no executor change was needed; the scripted device now behaves like the real one andAFailureOfTheLookAheadCycleVisibleOnlyAfterDrain*/AFailureOfTheFirstCycleVisibleOnlyAfterDrainSurfacesAfterACancellationcover it.partial_accept_roundsis counted for every partial round, andAddStatsmerges it.ExpectPrefillTokens), not of the process: the executor no longer callssetenv.EnginesOnTwoOwnerThreadsKeepTheirOwnExpectedPrefillTotalsruns two Engines on two owner threads with their own scripted devices and checks that each device sees its own total in every chunk of a turn (first turn, continued turn, canceled turn, following request) and thatORT_QMOE_PREFILL_TOTAL_TOKENSis untouched.Testing
-DUSE_CUDA=ON -DENABLE_QWEN38_PLANE=ON, CUDA 13.0, H200/sm_90) on top of the reworked Add the qwen38_flash_graph model type and its device decode engine #2727 (85595784):engine_unit_tests --gtest_filter='*Qwen38*'135 passed, 0 failed. The full binary runs 1249 tests: 1209 passed, 22 skipped, 18 failed, and the 18 are exactly the failures of the unmodified base in this environment (theCuda*provider-bridge tests needGetProviderfrom the in-tree CUDA EP, which the rc5 plugin EP used here does not export; the threeModelPackage.*tests (TokenizerResolvesThroughSharedAsset,ExternalInitializersFolderResolvesThroughSharedAsset,FlatDirectoryRejectsSha256Reference) fail identically on unmodifiedmainand are unrelated to this change).-DUSE_CXX17=1(the Android configuration) and as C++20 with-Wall -Wextrawithout diagnostics.og.Engine(original head of this PR, H200, v12 package): token identity against the reference runner 6/6 (EOS mode) and 6/6 (fixed length) with equal cycle and accept counts, MTP stream equal to the no-MTP stream 6/6, andengine_behaviours.py27/27 in the bit-exact (--prefill steps) and the fast-prefill modes. The tip of the whole stack was re-qualified end to end on the rc5 package (see the final PR of the runtime series, [Qwen38] Adopt the SpeculativePrefixCommit contract of ORT #33474, replay conv states of both layouts, and fix the prefix-commit slot stride #2824).Motivation and Context
The Generator-hosted version of this loop could not share the Engine's request lifecycle (turns, cancellation, stop strings, statistics, queueing), which is what the serving stack uses. Hosting it as a static-path executor reuses all of that while keeping the single-slot device state (fixed in-place KV, recurrent-state bank ring) that cannot be paged. It is an alternative backend next to the paged Engine MTP path of #2714, not a replacement.
Limits of this PR: greedy only; one resident request; no prefix cache and no caller drafts; a continued turn or the replay after
RewindToStartOfTurnre-ingests the whole history (0.3 s at 2k, 3.2 s at 32k with the fast prefill); sessions up tokv_capacity - 4tokens; Linux + CUDA only. The GPU gates need an exported package, the CUDA EP and the contrib operators, which CI does not have, so CI covers the scripted-device tests only.Checklist